Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 99c87759639f58b267b4ecca724df7d0cc7d2821


Parents : 30ef885
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-03-10T04:26:43-05:00

Add retry functionality for failed messages in ConversationViewer and enhance message handling in MessagesPage

Changes
Diff

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index bf20c705..8f2d527b 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -838,6 +838,15 @@
>
{{ chatItem.lxmf_message.state === "rejected" ? "Rejected" : "Failed" }}
</span>
+ <button
+ v-if="['failed', 'cancelled'].includes(chatItem.lxmf_message.state)"
+ type="button"
+ class="ml-0.5 p-0.5 rounded hover:bg-white/20 transition-colors"
+ title="Retry sending"
+ @click.stop="retrySendingMessage(chatItem)"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-3 text-white" />
+ </button>
<!-- delivered: double check -->
<MaterialDesignIcon
@@ -1214,6 +1223,21 @@
<MaterialDesignIcon icon-name="code-json" class="size-4 text-gray-400" />
<span class="font-medium">View Raw LXM</span>
</button>
+ <button
+ v-if="
+ messageContextMenu.chatItem?.is_outbound &&
+ ['failed', 'cancelled'].includes(messageContextMenu.chatItem?.lxmf_message?.state)
+ "
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-all active:scale-95"
+ @click="
+ retrySendingMessage(messageContextMenu.chatItem);
+ messageContextMenu.show = false;
+ "
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-4" />
+ <span class="font-medium">Retry</span>
+ </button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<button
type="button"
@@ -2613,52 +2637,54 @@ export default {
}
GlobalEmitter.emit("compose-new-message", destinationHash);
},
+ normalizeLxmfMessage(msg, isOutbound) {
+ const normalized = { ...msg };
+ if (!normalized.created_at && normalized.timestamp) {
+ normalized.created_at = new Date(normalized.timestamp * 1000).toISOString();
+ }
+ if (isOutbound && normalized.state === "unknown") {
+ normalized.state = "outbound";
+ }
+ return normalized;
+ },
onLxmfMessageReceived(lxmfMessage) {
- // only add if it's for the current conversation
if (lxmfMessage.source_hash !== this.selectedPeer?.destination_hash) {
return;
}
- // add inbound message to ui
this.chatItems.push({
type: "lxmf_message",
- lxmf_message: lxmfMessage,
+ is_outbound: false,
+ lxmf_message: this.normalizeLxmfMessage(lxmfMessage, false),
});
- // mark conversation as read
const conversation = this.findConversation(this.selectedPeer.destination_hash);
if (conversation) {
this.markConversationAsRead(conversation);
}
- // auto scroll to bottom if we want to
if (this.autoScrollOnNewMessage) {
this.scrollMessagesToBottom();
}
- // auto load audio
this.autoLoadAudioAttachments();
},
onLxmfMessageCreated(lxmfMessage) {
- // only add if it's for the current conversation
if (lxmfMessage.destination_hash !== this.selectedPeer?.destination_hash) {
return;
}
- // add new outbound lxmf message from server
if (!this.isLxmfMessageInUi(lxmfMessage.hash)) {
this.chatItems.push({
type: "lxmf_message",
- lxmf_message: lxmfMessage,
+ lxmf_message: this.normalizeLxmfMessage(lxmfMessage, true),
is_outbound: true,
});
}
- // auto load audio
this.autoLoadAudioAttachments();
},
onLxmfMessageUpdated(lxmfMessage) {
- // find existing chat item by lxmf message hash
const lxmfMessageHash = lxmfMessage.hash;
const chatItemIndex = this.chatItems.findIndex(
(chatItem) => chatItem.lxmf_message?.hash === lxmfMessageHash
@@ -2667,8 +2693,6 @@ export default {
return;
}
- // update lxmf message from server, while ensuring ui updates from nested object change
- // we merge to preserve client-side only fields or database fields not present in state updates
this.chatItems[chatItemIndex].lxmf_message = {
...this.chatItems[chatItemIndex].lxmf_message,
...lxmfMessage,
@@ -3271,16 +3295,14 @@ export default {
},
});
- // add outbound message to ui
if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
- lxmf_message: response.data.lxmf_message,
+ lxmf_message: this.normalizeLxmfMessage(response.data.lxmf_message, true),
is_outbound: true,
});
}
} else {
- // send first image with message text and other fields
const firstImage = images[0];
const firstFields = {
...fields,
@@ -3300,16 +3322,14 @@ export default {
},
});
- // add outbound message to ui
if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
- lxmf_message: response.data.lxmf_message,
+ lxmf_message: this.normalizeLxmfMessage(response.data.lxmf_message, true),
is_outbound: true,
});
}
- // send subsequent images as separate messages with image name as content
for (let i = 1; i < images.length; i++) {
const image = images[i];
const subsequentFields = {
@@ -3326,17 +3346,15 @@ export default {
},
});
- // add outbound message to ui
if (!this.isLxmfMessageInUi(subResponse.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
- lxmf_message: subResponse.data.lxmf_message,
+ lxmf_message: this.normalizeLxmfMessage(subResponse.data.lxmf_message, true),
is_outbound: true,
});
}
} catch (subError) {
console.error(`Failed to send image ${i + 1}:`, subError);
- // we continue sending other images even if one fails
}
}
}
@@ -3390,11 +3408,9 @@ export default {
}
},
async retrySendingMessage(chatItem) {
- // force delete existing message
await this.deleteChatItem(chatItem, false);
try {
- // send message to reticulum
const replyQuoted =
chatItem.lxmf_message.fields?.reply_quoted_content ||
(chatItem.lxmf_message.reply_to_hash &&
@@ -3409,19 +3425,16 @@ export default {
},
});
- // add outbound message to ui
if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
- lxmf_message: response.data.lxmf_message,
+ lxmf_message: this.normalizeLxmfMessage(response.data.lxmf_message, true),
is_outbound: true,
});
}
- // always scroll to bottom since we just sent a message
this.scrollMessagesToBottom();
} catch (e) {
- // show error
const message = e.response?.data?.message ?? "failed to send message";
DialogUtils.alert(message);
console.log(e);

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index dc73404c..e2fb5c73 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -262,8 +262,10 @@ export default {
return;
}
+ const existingConversation = this.conversations.find((c) => c.destination_hash === destinationHash);
this.onPeerClick({
- display_name: "Unknown Peer",
+ display_name: existingConversation?.display_name ?? "Anonymous Peer",
+ custom_display_name: existingConversation?.custom_display_name ?? null,
destination_hash: destinationHash,
});
},
@@ -291,10 +293,17 @@ export default {
break;
}
case "lxmf.delivery": {
- // reload conversations when a new message is received
await this.getConversations();
break;
}
+ case "lxmf_message_created": {
+ this.onOutboundMessageCreated(json.lxmf_message);
+ break;
+ }
+ case "lxmf_message_state_updated": {
+ this.onOutboundMessageStateUpdated(json.lxmf_message);
+ break;
+ }
case "lxmf.telemetry": {
// update tracking status if peer matches
const destHash = json.destination_hash;
@@ -422,6 +431,87 @@ export default {
this.isLoadingMore = false;
}
},
+ peerHashFromMessage(msg) {
+ return msg.is_incoming ? msg.source_hash : msg.destination_hash;
+ },
+ onOutboundMessageCreated(msg) {
+ const peerHash = this.peerHashFromMessage(msg);
+ const idx = this.conversations.findIndex((c) => c.destination_hash === peerHash);
+ if (idx !== -1) {
+ const conv = this.conversations[idx];
+ conv.latest_message_preview = msg.content;
+ conv.latest_message_title = msg.title;
+ conv.latest_message_created_at = msg.timestamp;
+ conv.updated_at = new Date(msg.timestamp * 1000).toISOString();
+ } else {
+ const peer = this.peers[peerHash];
+ this.conversations.unshift({
+ destination_hash: peerHash,
+ display_name: peer?.display_name ?? this.selectedPeer?.display_name ?? "Anonymous Peer",
+ custom_display_name: peer?.custom_display_name ?? this.selectedPeer?.custom_display_name ?? null,
+ contact_image: peer?.contact_image ?? null,
+ lxmf_user_icon: peer?.lxmf_user_icon ?? null,
+ is_unread: false,
+ is_tracking: peer?.is_tracking ?? false,
+ failed_messages_count: 0,
+ has_attachments: false,
+ latest_message_preview: msg.content,
+ latest_message_title: msg.title,
+ latest_message_created_at: msg.timestamp,
+ updated_at: new Date(msg.timestamp * 1000).toISOString(),
+ is_contact: false,
+ });
+ this.resolvePeerDisplayName(peerHash);
+ }
+ },
+ onOutboundMessageStateUpdated(msg) {
+ const peerHash = this.peerHashFromMessage(msg);
+ const conv = this.conversations.find((c) => c.destination_hash === peerHash);
+ if (!conv) return;
+
+ const oldState = conv._lastKnownState;
+ const newState = msg.state;
+ conv._lastKnownState = newState;
+
+ if (newState === "failed" && oldState !== "failed") {
+ conv.failed_messages_count = (conv.failed_messages_count || 0) + 1;
+ } else if (oldState === "failed" && newState !== "failed") {
+ conv.failed_messages_count = Math.max(0, (conv.failed_messages_count || 1) - 1);
+ }
+ },
+ async resolvePeerDisplayName(peerHash) {
+ try {
+ const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
+ params: { search: peerHash, limit: 1 },
+ });
+ const results = response.data.conversations;
+ if (!results || results.length === 0) return;
+
+ const fresh = results[0];
+ if (fresh.destination_hash !== peerHash) return;
+
+ const conv = this.conversations.find((c) => c.destination_hash === peerHash);
+ if (conv) {
+ if (fresh.display_name) conv.display_name = fresh.display_name;
+ if (fresh.custom_display_name) conv.custom_display_name = fresh.custom_display_name;
+ if (fresh.contact_image) conv.contact_image = fresh.contact_image;
+ if (fresh.lxmf_user_icon) conv.lxmf_user_icon = fresh.lxmf_user_icon;
+ if (fresh.is_contact) conv.is_contact = fresh.is_contact;
+ }
+
+ if (this.selectedPeer && this.selectedPeer.destination_hash === peerHash) {
+ if (fresh.display_name && fresh.display_name !== this.selectedPeer.display_name) {
+ this.selectedPeer = {
+ ...this.selectedPeer,
+ display_name: fresh.display_name,
+ custom_display_name: fresh.custom_display_name ?? this.selectedPeer.custom_display_name,
+ };
+ }
+ }
+ } catch {
+ // non-critical
+ }
+ },
async getFolders() {
try {
const response = await window.axios.get("/api/v1/lxmf/folders");


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────